TypeScript 97.5%
SQL 1.4%
Python 0.8%
1import Link from "next/link";2import { notFound } from "next/navigation";3import { ArrowLeft, ArrowRight, KeyRound } from "lucide-react";4import { PLAN_LIMITS, normalizePlan } from "@fetcha/core";5import { getWorkspace } from "@/lib/session";6import { getOrgProject, listApiKeys, monthlyUsage } from "@/lib/queries/account";7import { formatDate, formatDateOnly, formatNumber, formatPercent, formatUsd, timeAgo } from "@/lib/format";8import { maskedKey } from "@/lib/account-snippets";9import { PageHeader, SectionTitle } from "@/components/ui/page-header";10import { Badge, StatusBadge } from "@/components/ui/badge";11import { Button } from "@/components/ui/button";12import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";13import { Stat, StatGrid } from "@/components/ui/stat";14import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";15import { EmptyState } from "@/components/ui/empty-state";16import { Alert } from "@/components/ui/alert";17import { ProjectForm } from "@/components/dashboard/projects/project-form";18import { ArchiveProjectButton, SelectProjectButton } from "@/components/dashboard/projects/project-actions";19import { CreateKeyDialog } from "@/components/dashboard/api-keys/create-key-dialog";2021export const dynamic = "force-dynamic";2223export default async function ProjectDetailPage({ params }: { params: Promise<{ id: string }> }) {24 const { id } = await params;25 const ws = await getWorkspace();26 const project = await getOrgProject(ws.organization.id, id);27 if (!project) notFound();2829 const [usage, keys] = await Promise.all([monthlyUsage(ws.organization.id, project.id), listApiKeys(ws.organization.id, project.id)]);30 const activeKeys = keys.filter((k) => k.status === "active");31 const current = ws.project.id === project.id;32 const archived = Boolean(project.archivedAt);33 const planLimits = PLAN_LIMITS[normalizePlan(ws.organization.plan)];34 const requestCap = project.monthlyRequestLimit ?? planLimits.monthly_requests;35 const requestPct = requestCap > 0 && requestCap < Number.MAX_SAFE_INTEGER ? Math.min(100, (usage.requests / requestCap) * 100) : null;36 const spendPct = project.hardLimitUsd ? Math.min(100, (usage.spendUsd / project.hardLimitUsd) * 100) : null;3738 return (39 <div className="grid gap-6">40 <div>41 <Link href="/dashboard/projects" className="inline-flex items-center gap-1 text-[12.5px] text-fg-muted underline-offset-4 hover:text-fg hover:underline">42 <ArrowLeft className="size-3.5" /> All projects43 </Link>44 </div>45 <PageHeader46 eyebrow={<span className="font-mono normal-case tracking-normal">{project.id}</span>}47 title={48 <span className="flex flex-wrap items-center gap-2">49 {project.name}50 <Badge variant={project.environment === "production" ? "success" : "warning"}>{project.environment}</Badge>51 {current ? (52 <Badge variant="accent" dot>53 Current54 </Badge>55 ) : null}56 {archived ? <StatusBadge status="archived" /> : null}57 </span>58 }59 description={project.description || `Created ${formatDate(project.createdAt)}.`}60 actions={61 <>62 {!current && !archived ? (63 <SelectProjectButton projectId={project.id} size="sm">64 Make current65 </SelectProjectButton>66 ) : null}67 <Button asChild size="sm" variant="outline">68 <Link href={`/dashboard/api-keys?project=${project.id}`}>69 <KeyRound /> API keys70 </Link>71 </Button>72 </>73 }74 />7576 {archived ? <Alert variant="warning" title="This project is archived">Its keys no longer authenticate and it is hidden from the project switcher. Contact support@fetcha.co to restore it.</Alert> : null}7778 <StatGrid cols={4}>79 <Stat label="Requests this month" value={formatNumber(usage.requests)} hint={requestPct !== null ? `${formatPercent(requestPct, 0)} of ${formatNumber(requestCap)}` : "no cap"} />80 <Stat label="Spend this month" value={formatUsd(usage.spendUsd)} hint={project.hardLimitUsd ? `hard limit ${formatUsd(project.hardLimitUsd)}` : "no hard limit"} />81 <Stat label="Active keys" value={formatNumber(activeKeys.length)} hint={`${keys.length - activeKeys.length} inactive`} />82 <Stat label="Created" value={formatDateOnly(project.createdAt)} mono={false} hint={`updated ${timeAgo(project.updatedAt)}`} />83 </StatGrid>8485 <div className="grid gap-6 lg:grid-cols-[1fr_320px]">86 <div className="grid gap-6">87 <Card>88 <CardHeader>89 <CardTitle>Project settings</CardTitle>90 <CardDescription>Defaults apply to requests that omit the field. Limits are enforced per calendar month.</CardDescription>91 </CardHeader>92 <CardContent>93 <ProjectForm94 mode="edit"95 projectId={project.id}96 initial={{97 name: project.name,98 description: project.description,99 environment: project.environment,100 defaultCountry: project.defaultCountry,101 defaultNetwork: project.defaultNetwork,102 logLevel: project.logLevel,103 softLimitUsd: project.softLimitUsd,104 hardLimitUsd: project.hardLimitUsd,105 monthlyRequestLimit: project.monthlyRequestLimit,106 }}107 />108 </CardContent>109 </Card>110111 <section>112 <SectionTitle113 right={114 <Link href={`/dashboard/api-keys?project=${project.id}`} className="inline-flex items-center gap-1 text-[12.5px] text-fg-muted underline-offset-4 hover:text-fg hover:underline">115 Manage keys <ArrowRight className="size-3.5" />116 </Link>117 }118 >119 API keys · {keys.length}120 </SectionTitle>121 {keys.length === 0 ? (122 <EmptyState123 compact124 icon={KeyRound}125 title="No keys for this project"126 description="Generate a key to call the API on behalf of this project."127 action={!archived ? <CreateKeyDialog projects={[{ id: project.id, name: project.name, environment: project.environment }]} defaultProjectId={project.id} /> : undefined}128 />129 ) : (130 <div className="overflow-hidden rounded-lg border border-border bg-bg-elevated shadow-xs">131 <Table>132 <TableHeader>133 <TableRow className="hover:bg-transparent">134 <TableHead>Name</TableHead>135 <TableHead>Key</TableHead>136 <TableHead>Mode</TableHead>137 <TableHead>Last used</TableHead>138 <TableHead>Status</TableHead>139 </TableRow>140 </TableHeader>141 <TableBody>142 {keys.slice(0, 8).map((k) => (143 <TableRow key={k.id} className={k.status !== "active" ? "text-fg-muted" : undefined}>144 <TableCell className="font-medium text-fg">{k.name}</TableCell>145 <TableCell>146 <code className="font-mono text-[12.5px]">{maskedKey(k.keyPrefix, k.last4)}</code>147 </TableCell>148 <TableCell>149 <Badge variant={k.mode === "live" ? "success" : "warning"}>{k.mode}</Badge>150 </TableCell>151 <TableCell className="tabular">{timeAgo(k.lastUsedAt)}</TableCell>152 <TableCell>153 <StatusBadge status={k.status} />154 </TableCell>155 </TableRow>156 ))}157 </TableBody>158 </Table>159 {keys.length > 8 ? (160 <div className="border-t border-border px-4 py-2 text-[12.5px] text-fg-muted">161 <Link href={`/dashboard/api-keys?project=${project.id}`} className="underline-offset-4 hover:underline">162 View all {keys.length} keys163 </Link>164 </div>165 ) : null}166 </div>167 )}168 </section>169 </div>170171 <div className="grid gap-6 self-start">172 <Card>173 <CardHeader>174 <CardTitle>Spending limits</CardTitle>175 <CardDescription>Month-to-date against this project's limits.</CardDescription>176 </CardHeader>177 <CardContent className="grid gap-4 text-[13px]">178 <LimitRow label="Requests" value={`${formatNumber(usage.requests)} / ${requestCap < Number.MAX_SAFE_INTEGER ? formatNumber(requestCap) : "∞"}`} pct={requestPct} note={project.monthlyRequestLimit ? "project cap" : `${planLimits.label} — no monthly cap`} />179 <LimitRow label="Spend" value={`${formatUsd(usage.spendUsd)}${project.hardLimitUsd ? ` / ${formatUsd(project.hardLimitUsd)}` : ""}`} pct={spendPct} note={project.hardLimitUsd ? "hard limit" : "no hard limit"} />180 <div className="flex items-center justify-between border-t border-border pt-3 text-fg-muted">181 <span>Soft limit (alert)</span>182 <span className="font-mono tabular text-fg">{project.softLimitUsd ? formatUsd(project.softLimitUsd) : "—"}</span>183 </div>184 <p className="text-[12px] text-fg-subtle">185 Organization-wide limits live in{" "}186 <Link href="/dashboard/settings" className="underline-offset-4 hover:underline">187 Settings188 </Link>189 .190 </p>191 </CardContent>192 </Card>193194 {!archived ? (195 <Card className="border-danger/30">196 <CardHeader>197 <CardTitle className="text-danger">Danger zone</CardTitle>198 <CardDescription>Archiving hides the project and disables its keys.</CardDescription>199 </CardHeader>200 <CardContent>201 <ArchiveProjectButton projectId={project.id} name={project.name} disabled={ws.projects.length <= 1} disabledReason="You need at least one active project." />202 </CardContent>203 </Card>204 ) : null}205 </div>206 </div>207 </div>208 );209}210211function LimitRow({ label, value, pct, note }: { label: string; value: string; pct: number | null; note: string }) {212 return (213 <div className="grid gap-1.5">214 <div className="flex items-center justify-between">215 <span className="text-fg-muted">{label}</span>216 <span className="font-mono tabular">{value}</span>217 </div>218 <div className="h-1.5 overflow-hidden rounded-full bg-bg-muted" role="progressbar" aria-valuenow={pct ?? undefined} aria-valuemin={0} aria-valuemax={100} aria-label={`${label} used`}>219 {pct !== null ? <div className={pct >= 90 ? "h-full bg-danger" : pct >= 70 ? "h-full bg-warning" : "h-full bg-accent"} style={{ width: `${pct}%` }} /> : null}220 </div>221 <div className="text-[11.5px] text-fg-subtle">{note}</div>222 </div>223 );224}225